# -*- coding: utf-8 -*-
# -- This file is part of the Apio project
# -- (C) 2016-2019 FPGAwars
# -- Author Jesús Arroyo
# -- License GPLv2
"""Manage the apio profile file"""

import json
import sys
from enum import Enum
from dataclasses import dataclass
from datetime import datetime
from typing import Dict, Optional, Any, List, Tuple
from pathlib import Path
import requests
from jsonschema import validate
from jsonschema.exceptions import ValidationError
from apio.common import apio_console
from apio.common.apio_console import cout
from apio.common.apio_styles import INFO, EMPH3, ERROR
from apio.utils import util, jsonc

# -- JSON schema for validating a remote config file.
REMOTE_CONFIG_SCHEMA = {
    "$schema": "https://json-schema.org/draft/2020-12/schema",
    "type": "object",
    "required": ["packages"],
    "properties": {
        # -- Packages
        "packages": {
            "type": "object",
            "patternProperties": {
                "^.*$": {
                    "type": "object",
                    "required": ["repository", "release"],
                    "properties": {
                        # -- Repository
                        "repository": {
                            "type": "object",
                            "required": ["organization", "name"],
                            "properties": {
                                # -- Repo organization. e.g. "fpgawars"
                                "organization": {"type": "string"},
                                # -- Repo name, e.g. 'examples'
                                "name": {"type": "string"},
                            },
                            "additionalProperties": False,
                        },
                        # -- Release.
                        "release": {
                            "type": "object",
                            "required": [
                                "tag",
                                "package",
                            ],
                            "properties": {
                                # -- Tag
                                "tag": {
                                    "type": "string",
                                    "pattern": r"^\d{4}\-\d{2}\-\d{2}$",
                                },
                                # -- Package
                                "package": {"type": "string"},
                            },
                            "additionalProperties": False,
                        },
                    },
                    "additionalProperties": False,
                }
            },
            "additionalProperties": False,
        }
    },
    "additionalProperties": False,
}


class RemoteConfigPolicy(Enum):
    """Represents possible requirements from the remote config."""

    # -- Config is being used but can be a cached value, as long that it's
    # -- not too old.
    CACHED_OK = 1
    # -- Config is being used and a fresh copy is that was fetch in this
    # -- invocation of Apio is required.
    GET_FRESH = 2


@dataclass(frozen=True)
class PackageRemoteConfig:
    """Contains a package info from the remote config."""

    # -- E.g. "tools-oss-cad-suite"
    repo_name: str
    # -- E.g. "FPGAwars"
    repo_organization: str
    # -- E.g. "0.2.3"
    release_version: str
    # -- E.g. "${YYYY-MM-DD}""
    release_tag: str
    # -- E.g. "apio-oss-cad-suite-${PLATFORM}-${YYYYMMDD}.zip"
    release_file: str


def get_datetime_stamp(dt: Optional[datetime] = None) -> str:
    """Returns a string with time now as yyyy-mm-dd-hh-mm"""
    if dt is None:
        dt = datetime.now()
    return dt.strftime("%Y-%m-%d-%H-%M")


def days_between_datetime_stamps(
    ts1: str, ts2: str, default: Any
) -> Optional[int]:
    """Given two values generated by get_datetime_stamp(), return the
    number of days from ts1 to ts2. The value can be negative if ts2 is
    earlier than ts1. Returns the given 'default' value if either timestamp
    is invalid."""

    # -- The parsing format.
    fmt = "%Y-%m-%d-%H-%M"

    # -- Convert to timedates
    try:
        datetime1 = datetime.strptime(ts1, fmt)
        datetime2 = datetime.strptime(ts2, fmt)
    except ValueError:
        return default

    # -- Round to beginning of day.
    day1 = datetime(datetime1.year, datetime1.month, datetime1.day)
    day2 = datetime(datetime2.year, datetime2.month, datetime2.day)

    # -- Compute the diff in days.
    delta_days: int = (day2 - day1).days

    # -- All done.
    assert isinstance(delta_days, int)
    return delta_days


def minutes_between_datetime_stamps(
    ts1: str, ts2: str, default: Any
) -> Optional[int]:
    """Return the number of minutes from ts1 to ts2 or default if any of
    the timestamps is invalid."""

    # -- The parsing format.
    fmt = "%Y-%m-%d-%H-%M"

    # -- Convert to timedates
    try:
        datetime1 = datetime.strptime(ts1, fmt)
        datetime2 = datetime.strptime(ts2, fmt)
    except ValueError:
        return default

    # -- Calculate the diff in minutes.
    delta_minutes = int((datetime2 - datetime1).total_seconds() / 60)
    assert isinstance(delta_minutes, int), type(delta_minutes)
    return delta_minutes


class Profile:
    """Class for managing the apio profile file
    ex. ~/.apio/profile.json
    """

    # pylint: disable=too-many-instance-attributes

    # -- Only these instance vars are allowed.
    __slots__ = (
        "_profile_path",
        "_packages_index_path",
        "remote_config_url",
        "remote_config_ttl_days",
        "remote_config_retry_minutes",
        "_remote_config_policy",
        "_cached_remote_config",
        "preferences",
        "installed_packages",
    )

    def __init__(
        self,
        home_dir: Path,
        packages_dir: Path,
        remote_config_url_template: str,
        remote_config_ttl_days: int,
        remote_config_retry_minutes: int,
        remote_config_policy: RemoteConfigPolicy,
    ):
        """remote_config_url_template is a url string with the
        placeholder {major} and {minor} for the apio's major and minor
        version. '"""

        # pylint: disable=too-many-arguments
        # pylint: disable=too-many-positional-arguments

        # -- Sanity check
        assert isinstance(remote_config_ttl_days, int)
        assert 0 < remote_config_ttl_days <= 30

        # -- Sanity check
        assert isinstance(remote_config_retry_minutes, int)
        assert 0 < remote_config_retry_minutes <= (60 * 24)

        # -- Resolve and cache the remote config url. Replaced the placeholders
        # -- with the major and minor versions of apio. Path version is
        # -- not used.
        ver_tuple = util.get_apio_version_tuple()
        url = remote_config_url_template
        url = url.replace("{major}", str(ver_tuple[0]))
        url = url.replace("{minor}", str(ver_tuple[1]))
        self.remote_config_url = url

        # -- Save remote url ttl setting.
        self.remote_config_ttl_days = remote_config_ttl_days

        # -- Save the remote config fetch retry minutes.
        self.remote_config_retry_minutes = remote_config_retry_minutes

        # -- Save remote config policy.
        self._remote_config_policy = remote_config_policy

        # -- Verify that we resolved all the placeholders.
        assert "{" not in self.remote_config_url, self.remote_config_url

        if util.is_debug(1):
            cout(f"Remote config url: {self.remote_config_url}")

        # ---- Set the default parameters

        # User preferences
        self.preferences = {}

        # -- Installed package versions
        self.installed_packages = {}

        # -- A copy of remote config.
        self._cached_remote_config = {}

        # -- Cache the profile file path
        # -- Ex. '/home/obijuan/.apio/profile.json'
        self._profile_path = home_dir / "profile.json"

        # -- Cache the packages index file path
        # -- Ex. '/home/obijuan/.apio/packages/installed_packages.json'
        self._packages_index_path = packages_dir / "installed_packages.json"

        # -- Read the profile from file, if exists.
        self._load_profile_file()

        # -- Read the installed packages file, if exists.
        self._load_installed_packages_file()

        # -- Apply config policy
        self._apply_remote_config_policy()

    def _apply_remote_config_policy(self) -> None:
        """Called after loading the profile file, to apply the remote config
        policy for this invocation."""

        # -- Case 1: A fresh config is required for the current command.
        if self._remote_config_policy == RemoteConfigPolicy.GET_FRESH:
            self._fetch_and_update_remote_config(error_is_fatal=True)
            return

        # -- Case 2: A fresh config is optional but there is no cached
        # -- config so practically it's required.
        assert self._remote_config_policy == RemoteConfigPolicy.CACHED_OK
        if not self._cached_remote_config:
            if util.is_debug(1):
                cout("Saved remote config is not available.", style=INFO)
            self._fetch_and_update_remote_config(error_is_fatal=True)
            return

        # -- Case 3: May need to fetch a new config but can continue with
        # -- the cached config in case of a fetch failure.
        #
        # -- Get the cached config metadata.
        cashed_config_metadata = self._cached_remote_config.get("metadata", {})
        last_fetch_timestamp = cashed_config_metadata.get("loaded-at", "")
        last_fetch_url = cashed_config_metadata.get("loaded-from", "")

        # -- Determine if we need a new config because the remote config URL
        # -- was changed (e.g. with APIO_REMOTE_CONFIG_URL)
        url_changed = last_fetch_url != self.remote_config_url

        # -- Determine if there is time related reason to fetch a new config.
        datetime_stamp_now = get_datetime_stamp()
        days_since_last_fetch = days_between_datetime_stamps(
            last_fetch_timestamp, datetime_stamp_now, default=99999
        )
        time_valid = 0 <= days_since_last_fetch < self.remote_config_ttl_days

        # -- Determine if we already tried recently to refresh this config and
        # -- failed.
        refresh_failure_timestamp = cashed_config_metadata.get(
            "refresh-failure-on", ""
        )
        minutes_since_refresh_failure = minutes_between_datetime_stamps(
            refresh_failure_timestamp, datetime_stamp_now, default=99999
        )
        refresh_failed_recently = (
            0
            <= minutes_since_refresh_failure
            < self.remote_config_retry_minutes
        )

        # -- Dump info for debugging.
        if util.is_debug(1):
            cout(
                f"{days_since_last_fetch=}, {time_valid=}, {url_changed=}",
                f"{minutes_since_refresh_failure=}, "
                f"{refresh_failed_recently=}",
                style=EMPH3,
            )

        # -- Fetch the new config if needed.
        if url_changed or not time_valid:
            if not refresh_failed_recently:
                self._fetch_and_update_remote_config(error_is_fatal=False)

    @property
    def remote_config(self) -> Dict:
        """Returns the remote config that is applicable for this invocation.
        Should not called if the context was initialized with NO_CONFIG."""
        return self._cached_remote_config

    def add_package(self, name: str, version: str, platform_id: str, url: str):
        """Add a package to the profile class"""

        # -- Updated the installed package data.
        self.installed_packages[name] = {
            "version": version,
            "platform": platform_id,
            "loaded-by": util.get_apio_version_str(),
            "loaded-at": get_datetime_stamp(),
            "loaded-from": url,
        }
        # self._save()
        self._save_installed_packages()

    def set_preferences_theme(self, theme: str):
        """Set prefer theme name."""
        self.preferences["theme"] = theme
        self._save()
        self.apply_color_preferences()

    def remove_package(self, name: str):
        """Remove a package from the profile file"""

        if name in self.installed_packages.keys():
            del self.installed_packages[name]
            # self._save()
            self._save_installed_packages()

    @staticmethod
    def apply_color_preferences():
        """Apply currently preferred theme."""
        # -- If not specified, read the theme from file.
        theme: str = Profile.read_preferences_theme()

        # -- Apply to the apio console.
        apio_console.configure(theme_name=theme)

    @staticmethod
    def read_preferences_theme(*, default: str = "light") -> Optional[str]:
        """Returns the value of the theme preference or default if not
        specified. This is a static method because we may need this value
        before creating  the profile object, for example when printing command
        help.
        """

        profile_path = util.resolve_home_dir() / "profile.json"

        if not profile_path.exists():
            return default

        with open(profile_path, "r", encoding="utf8") as f:
            # -- Get the colors preferences value, if exists.
            data = json.load(f)
            preferences = data.get("preferences", {})
            theme = preferences.get("theme", None)

        # -- Get the click context, if exists.
        return theme if theme else default

    def get_installed_package_info(self, package_name: str) -> Tuple[str, str]:
        """Return (package_version, platform_id) of the given installed
        package. Values are replaced with "" if not installed or a value is
        missing."""
        package_info = self.installed_packages.get(package_name, {})
        package_version = package_info.get("version", "")
        platform_id = package_info.get("platform", "")
        return (package_version, platform_id)

    def get_package_config(
        self,
        package_name: str,
    ) -> PackageRemoteConfig:
        """Given a package name, return the remote config information with the
        version and fetch information.
        """

        # -- Extract package's remote config.
        package_config = self.remote_config["packages"][package_name]
        repo_name = package_config["repository"]["name"]
        repo_organization = package_config["repository"]["organization"]
        release_tag = package_config["release"]["tag"]
        release_version = release_tag.replace("-", ".")
        release_file = package_config["release"]["package"]

        return PackageRemoteConfig(
            repo_name=repo_name,
            repo_organization=repo_organization,
            release_version=release_version,
            release_tag=release_tag,
            release_file=release_file,
        )

    def _load_profile_file(self):
        """Load the profile file if exists, e.g.
        /home/obijuan/.apio/profile.json)
        """

        # -- If profile file doesn't exist then nothing to do.
        if not self._profile_path.exists():
            return

        # -- Read the profile file as a json dict.
        with open(self._profile_path, "r", encoding="utf8") as f:
            data = json.load(f)

        # -- Determine if the cached remote config is usable.
        remote_config = data.get("remote-config", {})
        config_apio_version = remote_config.get("metadata", {}).get(
            "loaded-by", ""
        )
        config_usable = config_apio_version == util.get_apio_version_str()

        # -- Extract the fields. If remote config is of a different apio
        # -- version, drop it.
        self.preferences = data.get("preferences", {})
        self.installed_packages = data.get("installed-packages", {})
        self._cached_remote_config = remote_config if config_usable else {}

    def _load_installed_packages_file(self):
        """Load the installed packages index file if exists, e.g.
        /home/obijuan/.apio/packages/installed_packages.json)
        """

        if self._packages_index_path.exists():

            # -- Read the file as a json dict.
            with open(self._packages_index_path, "r", encoding="utf8") as f:
                self.installed_packages = json.load(f)

    def _save(self):
        """Save the profile file"""

        # -- Create the enclosing folder, if it does not exist yet
        path = self._profile_path.parent
        if not path.exists():
            path.mkdir()

        # -- Construct the json dict.
        data = {}
        if self.preferences:
            data["preferences"] = self.preferences

        if self._cached_remote_config:
            data["remote-config"] = self._cached_remote_config

        # -- Write to profile file.
        with open(self._profile_path, "w", encoding="utf8") as f:
            json.dump(data, f, indent=4)

        # -- Dump for debugging.
        if util.is_debug(1):
            cout("Saved profile:", style=EMPH3)
            cout(json.dumps(data, indent=2))

    def _save_installed_packages(self):
        """Save the installed packages file"""

        # -- Create the enclosing folder, if it does not exist yet
        path = self._packages_index_path.parent
        if not path.exists():
            path.mkdir()

        # -- Write to profile file.
        with open(self._packages_index_path, "w", encoding="utf8") as f:
            json.dump(self.installed_packages, f, indent=4)

        # -- Dump for debugging.
        if util.is_debug(1):
            cout("Saved installed packages index:", style=EMPH3)
            cout(json.dumps(self.installed_packages, indent=2))

    def _handle_config_refresh_failure(
        self, *, msg: List[str], error_is_fatal: bool
    ):
        """Called to handle a failure of a remote config refresh."""
        # -- Handle hard error.
        if error_is_fatal:
            cout(*msg, style=ERROR)
            sys.exit(1)

        # -- Handle soft error. We can continue with a cached config.
        # -- Sanity check, a cached config exists.
        assert self._cached_remote_config, "No cached remote config"

        # -- Print the soft warning.
        cout(*msg, style=INFO)
        cout("Will try again at a latter time.", style=INFO)

        # -- Memorize the time of the attempt so we don't retry too often.
        metadata = self._cached_remote_config["metadata"]
        metadata["refresh-failure-on"] = get_datetime_stamp()
        self._save()

    def _fetch_and_update_remote_config(self, *, error_is_fatal: bool) -> None:
        """Returns the apio remote config JSON dict."""

        # -- Fetch the config text. Returns None if error_is_fatal=False and
        # -- fetch failed.
        config_text: Optional[str] = self._fetch_remote_config_text(
            error_is_fatal=error_is_fatal
        )

        if config_text is None:
            # -- Sanity check, If error_is_fatal, _fetch_remote_config_text()
            # -- wouldn't return with None.
            assert not error_is_fatal
            return

        # -- Print the file's content for debugging
        if util.is_debug(1):
            cout(config_text)

        # -- Convert the jsonc to json by removing '//' comments.
        config_text = jsonc.to_json(config_text)

        # -- Parse the remote JSON config file into a dict.
        try:
            remote_config = json.loads(config_text)

        # -- Handle parsing error.
        except json.decoder.JSONDecodeError as exc:
            self._handle_config_refresh_failure(
                msg=[
                    "Failed to parse the latest Apio remote config file.",
                    f"{exc}",
                ],
                error_is_fatal=error_is_fatal,
            )
            return

        # -- Do some checks and fail if invalid. This is not an exhaustive
        # -- check.
        ok = self._check_downloaded_remote_config(
            remote_config, error_is_fatal=error_is_fatal
        )
        if not ok:
            return

        # -- Append remote config metadata. This also clear the
        # -- "refresh-failure-on" field if exists.
        metadata_dict = {}
        metadata_dict["loaded-by"] = util.get_apio_version_str()
        metadata_dict["loaded-at"] = get_datetime_stamp()
        metadata_dict["loaded-from"] = self.remote_config_url
        remote_config["metadata"] = metadata_dict

        self._cached_remote_config = remote_config
        self._save()

    def _check_downloaded_remote_config(
        self, remote_config: Dict, error_is_fatal: bool
    ) -> bool:
        """Check the downloaded remote config has a valid structure."""
        try:
            validate(instance=remote_config, schema=REMOTE_CONFIG_SCHEMA)
        except ValidationError as e:
            # -- Error.
            msg = ["Fetched remote config failed validation.", str(e)]
            self._handle_config_refresh_failure(
                msg=msg, error_is_fatal=error_is_fatal
            )
            return False

        # -- Ok.
        return True

    def _fetch_remote_config_text(self, error_is_fatal: bool) -> Optional[str]:
        """Fetches and returns the apio remote config JSON text. In case
        of an error, returns None."""

        # pylint: disable=broad-exception-caught

        # -- Announce the remote config url
        cout(f"Fetching '{self.remote_config_url}'")

        # -- If the URL has a file protocol, read from the file. This
        # -- is used mostly for testing of a new package version.
        if self.remote_config_url.startswith("file://"):
            file_path = self.remote_config_url[7:]
            try:
                with open(file_path, encoding="utf-8") as f:
                    file_text = f.read()
            except Exception as e:
                # -- Since local config file can be fixed and doesn't depend
                # -- on availability of a remote server, we make this a fatal
                # -- error instead of a soft error.
                self._handle_config_refresh_failure(
                    msg=["Failed to read a local config file.", str(e)],
                    error_is_fatal=True,
                )

            # -- Local file read OK.
            return file_text

        # -- Here is the normal case where the config url is not of a local
        # -- file but at a remote URL.

        # -- Fetch the remote config. With timeout = 10, this failed a
        # -- few times on github workflow tests so increased to 25.
        try:
            resp: requests.Response = requests.get(
                self.remote_config_url, timeout=25
            )
            error_msg = None
        except Exception as e:
            error_msg = str(e)

        # -- Error codes such as 404 don't cause an exception so we handle
        # -- them here separately.
        if (error_msg is None) and (resp.status_code != 200):
            error_msg = (
                f"Expected HTTP status code 200, got {resp.status_code}."
            )

        # -- If an error was found then handle it.
        if error_msg is not None:
            self._handle_config_refresh_failure(
                msg=[
                    "Downloading of the latest Apio remote config "
                    "file failed.",
                    error_msg,
                ],
                error_is_fatal=error_is_fatal,
            )
            return None

        # -- Done ok.
        assert resp.text is not None
        return resp.text
